package com.simpou.commons.utils.file;

import com.simpou.commons.utils.lang.RefHashSet;
import com.thoughtworks.xstream.XStream;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;
import javax.xml.transform.Templates;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidParameterException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Operações úteis sobre arquivos XML.
 *
 * @author Jonas Pereira
 * @version 2011-09-21
 * @since 2011-09-01
 */
public final class XMLHelper {

    private static final Map<XmlCacheItem, Templates> XSL_CACHE = new HashMap<XmlCacheItem, Templates>();

    private static final Map<String, XmlCacheItem> XSL_CACHE_PATHS = new HashMap<String, XmlCacheItem>();

    private static final Map<XmlCacheItem, Schema> XSD_CACHE = new HashMap<XmlCacheItem, Schema>();

    private static final Map<String, XmlCacheItem> XSD_CACHE_PATHS = new HashMap<String, XmlCacheItem>();

    /**
     * Document.
     */
    private Document doc;

    /**
     * Inicializa objetos de manipulação do xml.
     *
     * @param xmlFilePath Caminho completo do arquivo xml.
     * @throws javax.xml.parsers.ParserConfigurationException javax.xml.parsers.ParserConfigurationException.
     * @throws org.xml.sax.SAXException org.xml.sax.SAXException.
     * @throws java.io.IOException java.io.IOException.
     */
    public XMLHelper(final InputStream is)
            throws ParserConfigurationException, SAXException, IOException {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(false);

        DocumentBuilder docBuilder = dbf.newDocumentBuilder();
        doc = docBuilder.parse(is);
    }

    public XMLHelper(final String xmlFilePath)
            throws ParserConfigurationException, SAXException, IOException {
        this(new FileInputStream(xmlFilePath));
    }

    public XMLHelper(final byte[] xmlContent)
            throws ParserConfigurationException, SAXException, IOException {
        this(new ByteArrayInputStream(xmlContent));
    }

    /**
     * <p>getElementRoot.</p>
     *
     * @return Tag raíz do xml.
     */
    public Element getElementRoot() {
        return doc.getDocumentElement();
    }

    /**
     * <p>getElementsByName.</p>
     *
     * @param elementRoot Tag onde serão procurados os elementos internos.
     * @param elementName Nome dos elementos internos a serem retornados.
     * @return Elementos encontrados.
     */
    public static Set<Element> getElementsByName(final Element elementRoot,
                                                 final String elementName) {
        final RefHashSet<Element> elements = new RefHashSet<Element>();
        findElementsByName(elements, elementRoot, elementName);
        return elements.toSet();
    }

    /**
     * Ex.: dado o xml <root><tag>valor 1</tag><tag>valor 2</tag></roor>; element=root, subElementName=tag,
     * return={valor1, valor 2}.
     *
     * @param element        Tag onde serão procurados os elementos internos.
     * @param subElementName Nome dos elementos internos que contêm os valores a serem retornados.
     * @return Valores do elementos internos.
     */
    public List<String> getSubElementsValues(final Element element,
                                             final String subElementName) {
        NodeList childNodes = element.getChildNodes();
        List<String> values = new ArrayList<String>();
        Node node;

        for (int i = 0; i < childNodes.getLength(); i++) {
            node = childNodes.item(i);

            if (node.getNodeName().equals(subElementName)) {
                values.add(node.getTextContent());
            }
        }

        return values;
    }

    /**
     * Obs.: não suporta parâmetros nos elementos e valores dos elementos combinados com subelementos.
     *
     * @param <T>         Tipo do objeto a ser preenchido pelos valores do XML.
     * @param xmlFilePath Caminho completo do arquivo xml.
     * @param clasz       Classe do objeto a ser preenchido pelos valores do XML.
     * @return Objeto a preenchido pelos valores do XML.
     * @throws java.io.IOException Erro ao ler XML.
     */
    @SuppressWarnings("unchecked")
    public static <T> T fromXML(final String xmlFilePath, final Class<T> clasz)
            throws IOException {
        String content = FileHelper.read(xmlFilePath);
        XStream xStream = new XStream();
        Object object = xStream.fromXML(content);

        if (clasz.isAssignableFrom(object.getClass())) {
            return (T) object;
        } else {
            throw new InvalidParameterException("Invalid relationship between "
                    + "object and XML.");
        }
    }

    /**
     * <p>toXML.</p>
     *
     * @param xmlFilePath Caminho completo do arquivo xml.
     * @param object      Objeto cujos valores dos atributos serão gravados no XML.
     * @param backup      Se deve realizar backup do arquivo caso já exista.
     * @throws java.io.IOException Erro ao gravar XML.
     * @throws java.text.ParseException Erro ao converter objeto para XML.
     */
    public static void toXML(final String xmlFilePath, final Object object,
                             final boolean backup) throws IOException, ParseException {
        XStream xStream = new XStream();
        String content = xStream.toXML(object);
        FileHelper.write(xmlFilePath, content, false, backup);
    }

    /**
     * Encontra um elemento. Caso hajam sub-elementos com mesmo nome de um super-elemento, todos serão retornados. Caso
     * mais de um elemento for encontrado no mesmo nível, todos serão retornados.
     *
     * @param superNode   Elemento a partir do qual serão feitas as buscas.
     * @param elementName Nome do elemento a ser buscado.
     * @param addedNodes  Nós já adicionados.
     */
    private static void findElementsByName(final RefHashSet<Element> addedNodes,
                                           final Element superNode, final String elementName) {
        NodeList nodes = superNode.getChildNodes();
        NodeList elNodes;
        Node node;
        Element element;

        for (int i = 0; i < nodes.getLength(); i++) {
            node = nodes.item(i);
            if (addedNodes.contains(node)) {
                continue;
            }

            elNodes = superNode.getElementsByTagName(node.getNodeName());

            if (elNodes.getLength() > 0) {
                for (int j = 0; j < elNodes.getLength(); j++) {
                    element = (Element) elNodes.item(j);

                    if (element.getNodeName().equals(elementName)) {
                        addedNodes.addNew(element);
                    }

                    findElementsByName(addedNodes, element, elementName);
                }
            }
        }
    }

    @Getter
    @EqualsAndHashCode(of = { "date", "path" })
    private static class XmlCacheItem {

        private final long date;

        private final String path;

        private final File file;

        private XmlCacheItem(final File file) throws IOException {
            this.date = file.lastModified();
            this.path = file.getAbsolutePath();
            this.file = file;
        }
    }

    private static Transformer getNewTransformer(final XmlCacheItem cacheItem) throws IOException, TransformerConfigurationException {
        final Source xslSource = new StreamSource(cacheItem.getFile());
        final Templates templates = transformerFactory.newTemplates(xslSource);
        XSL_CACHE.put(cacheItem, templates);
        XSL_CACHE_PATHS.put(cacheItem.getPath(), cacheItem);
        return templates.newTransformer();
    }

    private static Transformer getTransformer(final File xslFile) throws IOException, TransformerConfigurationException {
        final XmlCacheItem cacheItem = new XmlCacheItem(xslFile);
        final Transformer transformer;
        if (XSL_CACHE_PATHS.containsKey(cacheItem.getPath())) {
            final XmlCacheItem cacheItemOld = XSL_CACHE_PATHS.get(cacheItem.getPath());
            if (cacheItemOld.equals(cacheItem)) {
                // cache ok, somente retorna
                transformer = XSL_CACHE.get(cacheItem).newTransformer();
            } else {
                // invalida cache, arquivo alterado
                XSL_CACHE_PATHS.remove(cacheItemOld.getPath());
                XSL_CACHE.remove(cacheItemOld);
                // atualiza cache
                transformer = getNewTransformer(cacheItem);
            }
        } else {
            // não está no cache
            transformer = getNewTransformer(cacheItem);
        }
        return transformer;
    }

    /**
     * Aplica uma transformação XSL a um XML.
     *
     * @param source  Conteúdo formatado em XML.
     * @param xslFile Arquivo XSL.
     * @return Resultado da transformação XSL.
     * @throws TransformerException Erro de compilação do XSL ou tranformação do XML.
     * @throws IOException Erro de acesso ao arquivo XSL.
     */
    public static byte[] transform(final byte[] source, final File xslFile) throws TransformerException, IOException {
        final InputStream is = new ByteArrayInputStream(source);
        final Source xmlSource = new StreamSource(is);
        return transform(xmlSource, xslFile);
    }

    private static final TransformerFactory transformerFactory = TransformerFactory.newInstance();

    /**
     * Aplica uma transformação XSL a um XML. Não realiza cache do XSL, há uma nova compilação a cada execução.
     *
     * @param source Conteúdo formatado em XML.
     * @param xsl Conteúdo formatado em XSL.
     * @return Resultado da transformação XSL.
     * @throws TransformerException Erro de compilação do XSL ou tranformação do XML.
     * @throws IOException Erro de acesso ao arquivo XSL.
     */
    public static byte[] transform(final byte[] source, final byte[] xsl) throws TransformerException, IOException {
        final Source xmlSource = new StreamSource(new ByteArrayInputStream(source));
        final Source xslSource = new StreamSource(new ByteArrayInputStream(xsl));

        final Templates templates = transformerFactory.newTemplates(xslSource);
        final Transformer transformer = templates.newTransformer();

        final ByteArrayOutputStream resultBuf = new ByteArrayOutputStream();
        transformer.transform(xmlSource, new StreamResult(resultBuf));

        return resultBuf.toByteArray();
    }

    /**
     * Aplica uma transformação XSL a um XML.
     *
     * @param xmlFile Arquivo XML a ser transformado.
     * @param xslFile Arquivo XSL.
     * @return Resultado da transformação XSL.
     * @throws TransformerException Erro de compilação do XSL ou tranformação do XML.
     * @throws IOException Erro de acesso ao arquivo XSL.
     */
    public static byte[] transform(final File xmlFile, final File xslFile) throws TransformerException, IOException {
        final Source xmlSource = new StreamSource(xmlFile);
        return transform(xmlSource, xslFile);
    }

    private static byte[] transform(final Source xmlSource, final File xslFile) throws TransformerException, IOException {
        final Transformer transformer = getTransformer(xslFile);
        final ByteArrayOutputStream resultBuf = new ByteArrayOutputStream();
        transformer.transform(xmlSource, new StreamResult(resultBuf));
        return resultBuf.toByteArray();
    }

    private static Schema getNewSchema(final XmlCacheItem cacheItem) throws SAXException {
        SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
        Schema schema = schemaFactory.newSchema(cacheItem.getFile());
        XSD_CACHE.put(cacheItem, schema);
        XSD_CACHE_PATHS.put(cacheItem.getPath(), cacheItem);
        return schema;
    }

    private static Schema getSchema(final File xsdFile) throws SAXException, IOException {
        //        final Source[] schemas = new Source[xsdFiles.length];
        //        for (int i = 0; i < schemas.length; i++) {
        //            final Source source = new StreamSource(xsdFiles[i]);
        //            schemas[i] = source;
        //        }

        final XmlCacheItem cacheItem = new XmlCacheItem(xsdFile);
        final Schema schema;
        if (XSD_CACHE_PATHS.containsKey(cacheItem.getPath())) {
            final XmlCacheItem cacheItemOld = XSD_CACHE_PATHS.get(cacheItem.getPath());
            if (cacheItemOld.equals(cacheItem)) {
                // cache ok, somente retorna
                schema = XSD_CACHE.get(cacheItem);
            } else {
                // invalida cache, arquivo alterado
                XSD_CACHE_PATHS.remove(cacheItemOld.getPath());
                XSD_CACHE.remove(cacheItemOld);
                // atualiza cache
                schema = getNewSchema(cacheItem);
            }
        } else {
            // não está no cache
            schema = getNewSchema(cacheItem);
        }

        return schema;
    }

    /**
     * Valida um XML segundo um ou mais esquemas.
     *
     * @param source  Conteúdo formatado em XML.
     * @param xsdFile Esquema de validação, arquivo XSD. Se houverem mais arquivos a serem utilizados por referência,
     *                desde que estejam no mesmo diretório, serão considerados automaticamente.
     * @throws SAXException Erros na validação.
     * @throws IOException Erro de acesso aos arquivos.
     * @throws SAXParseException Erros de validação.
     */
    public static void validate(final byte[] source, final File xsdFile) throws SAXException, IOException {
        final Source xmlSource = new StreamSource(new ByteArrayInputStream(source));
        validate(xmlSource, xsdFile);
    }

    /**
     * Valida um XML segundo um ou mais esquemas.
     *
     * @param xmlFile Arquivo XML a ser validado.
     * @param xsdFile Esquema de validação, arquivo XSD. Se houverem mais arquivos a serem utilizados por referência,
     *                desde que estejam no mesmo diretório, serão considerados automaticamente.
     * @throws SAXException Erros na validação.
     * @throws IOException Erro de acesso aos arquivos.
     * @throws SAXParseException Erros de validação.
     */
    public static void validate(final File xmlFile, final File xsdFile) throws SAXException, IOException {
        final Source xmlSource = new StreamSource(xmlFile);
        validate(xmlSource, xsdFile);
    }

    private static void validate(final Source xmlSource, final File xsdFile) throws SAXException, IOException {
        final Schema schema = getSchema(xsdFile);
        final Validator validator = schema.newValidator();
        validator.validate(xmlSource);
    }
}
